Popular Searches
Popular Course Categories
Popular Courses

Managing Loading Indicators, Empty States, and Retry Actions

Managing Loading Indicators, Empty States, and Retry Actions

Networking and REST API Integration

Managing Loading Indicators, Empty States, and Retry Actions in iOS

Course: iOS App Development

Topic: Managing Loading Indicators, Empty States, and Retry Actions

Course Link: iOS App Development Training

Demo Link: Register for a Course Demo

Website: JustAcademy


1. Introduction

Modern iOS applications frequently retrieve data from remote APIs, local databases, cloud services, and other sources. During these operations, users need clear feedback about what the application is doing. If an application displays a blank screen while loading, provides no explanation when a list is empty, or offers no way to recover from a failed request, users may assume that the application is broken.

Three important components of a reliable user experience are loading indicators, empty states, and retry actions.

  • Loading indicators: Inform users that an operation is in progress.
  • Empty states: Explain why there is no content to display and guide users toward the next action.
  • Retry actions: Allow users to attempt an operation again after a recoverable failure.

These components are particularly important in applications that use URLSession, SwiftUI, UIKit, UITableView, UICollectionView, or database-driven interfaces.

2. Learning Objectives

  • Understand the purpose of loading indicators in iOS.
  • Implement loading indicators using SwiftUI ProgressView.
  • Display activity indicators using UIKit.
  • Design meaningful empty states for lists and screens.
  • Handle successful responses containing no data.
  • Display appropriate error messages when requests fail.
  • Implement retry buttons and refresh actions.
  • Manage loading, content, empty, and error states.
  • Prevent duplicate requests and inconsistent UI updates.
  • Build a practical API-driven screen with complete loading and error handling.

3. Understanding Loading Indicators

A loading indicator is a visual component that communicates that an operation has started but has not yet completed. Network requests can take different amounts of time depending on internet connectivity, server response time, response size, and device performance.

Without a loading indicator, users may repeatedly tap buttons, leave the screen, or assume that the app is unresponsive.

3.1 Types of Loading Indicators

Indicator Type Description Example
Indeterminate spinner Shows that work is happening without a known completion percentage. Fetching a user profile.
Determinate progress bar Shows measurable progress toward completion. Uploading a file with known progress.
Inline loading message Displays a message near the content being loaded. Loading more search results.
Skeleton placeholder Shows placeholder shapes resembling the expected content. Loading product cards.
Pull-to-refresh indicator Shows that existing content is being refreshed. Refreshing a news feed.

3.2 When Should a Loading Indicator Appear?

  • When retrieving data from a remote API.
  • When submitting a registration or login form.
  • When refreshing a table or collection view.
  • When uploading files or processing user input.
  • When performing a database operation that takes noticeable time.

Loading feedback should be proportional to the operation. Small, fast operations may not need a prominent indicator, while longer tasks should provide visible feedback and, where appropriate, a cancellation option.

4. Managing Loading Indicators in SwiftUI

SwiftUI provides the built-in ProgressView component for displaying progress. It supports indeterminate activity indicators as well as determinate progress when the completion value is known.

Reference: Apple Developer Documentation — ProgressView.

4.1 Basic ProgressView

import SwiftUI

struct LoadingExampleView: View {
    var body: some View {
        VStack(spacing: 16) {
            ProgressView()
            Text("Loading data...")
                .foregroundStyle(.secondary)
        }
        .padding()
    }
}

The default ProgressView displays an indeterminate indicator. It is useful when the application knows that work is in progress but cannot calculate a reliable percentage.

4.2 Using a Loading State Variable

In a real application, the indicator should appear and disappear according to the status of the operation. A Boolean state variable can control its visibility.

import SwiftUI

struct SimpleLoadingView: View {
    @State private var isLoading = false

    var body: some View {
        VStack(spacing: 16) {
            if isLoading {
                ProgressView("Loading data...")
            } else {
                Text("Data is ready.")
            }

            Button("Start Loading") {
                isLoading = true
            }
        }
        .padding()
    }
}

This example demonstrates conditional rendering. In a complete application, the loading state must be reset when the operation succeeds or fails.

4.3 Displaying a Determinate Progress Bar

When an operation exposes measurable progress, use the determinate form of ProgressView.

struct FileUploadProgressView: View {
    @State private var progress = 0.65

    var body: some View {
        VStack(spacing: 12) {
            ProgressView(value: progress, total: 1.0)
            Text("Upload progress: \(Int(progress * 100))%")
        }
        .padding()
    }
}

In this example, the progress value is 0.65, representing 65% completion. In a real upload, update the value from actual transfer progress rather than using a fixed value.

5. Managing Loading Indicators in UIKit

UIKit provides UIActivityIndicatorView for displaying an indeterminate activity indicator. It can be used in view controllers, table views, collection views, and other UIKit screens.

5.1 Creating an Activity Indicator

import UIKit

class LoadingViewController: UIViewController {
    private let activityIndicator = UIActivityIndicatorView(
        style: .large
    )

    override func viewDidLoad() {
        super.viewDidLoad()

        activityIndicator.center = view.center
        activityIndicator.hidesWhenStopped = true
        view.addSubview(activityIndicator)
    }

    func startLoading() {
        activityIndicator.startAnimating()
    }

    func stopLoading() {
        activityIndicator.stopAnimating()
    }
}

The indicator is added to the view and configured to hide when stopped. Call startAnimating() when work begins and stopAnimating() when the operation finishes.

5.2 Important UIKit Methods

Method or Property Purpose
startAnimating() Starts the activity indicator.
stopAnimating() Stops the activity indicator.
hidesWhenStopped Controls whether the indicator is hidden after stopping.
isAnimating Indicates whether the indicator is currently animating.

For production layouts, use Auto Layout constraints or a stack view rather than relying only on a fixed center position. This helps the indicator adapt to different screen sizes and layout changes.

6. Understanding Empty States

An empty state appears when there is no content to display. This can be expected behavior rather than an error. For example, a new user may have no saved articles, a search may return no matches, or a task list may be empty because all tasks have been completed.

A useful empty state tells the user what happened and, when possible, offers a relevant next action.

6.1 Common Types of Empty States

Type Example Message Possible Action
First-use state You have not saved any articles yet. Explore articles.
Search state No results found for your search. Clear filters or change keywords.
Completed state All tasks are completed. Add a new task.
Filtered state No items match the selected filters. Reset filters.
Offline content state No saved content is available offline. Reconnect and download content.

6.2 Empty State vs. Error State

An empty state and an error state must not be treated as the same thing.

  • Empty state: The operation succeeded, but there is no content to display.
  • Error state: The operation failed, so the application could not obtain the expected result.
  • Loading state: The operation is still in progress, and its final result is not yet known.

For example, if an API successfully returns an empty array, the app should show an empty state. If the request fails because the device has no internet connection, the app should show an error state with an appropriate recovery option.

7. Creating Empty States in SwiftUI

SwiftUI can display empty states using standard views such as VStack, Image, Text, and Button. On supported operating systems, ContentUnavailableView provides a convenient standard layout for empty or unavailable content.

7.1 A Custom Empty State

import SwiftUI

struct EmptyArticlesView: View {
    let onExplore: () -> Void

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: "doc.text.magnifyingglass")
                .font(.system(size: 44))
                .foregroundStyle(.secondary)

            Text("No Articles Yet")
                .font(.title2.bold())

            Text("Explore articles to find something interesting.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)

            Button("Explore Articles", action: onExplore)
                .buttonStyle(.borderedProminent)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

The reusable view accepts a closure for the primary action. The parent view decides what should happen when the user taps the button.

7.2 Using ContentUnavailableView

import SwiftUI

struct EmptySearchView: View {
    var body: some View {
        ContentUnavailableView(
            "No Results",
            systemImage: "magnifyingglass",
            description: Text(
                "Try changing your search terms."
            )
        )
    }
}

ContentUnavailableView is available starting with iOS 17. For applications supporting earlier iOS versions, create a custom empty-state view with standard SwiftUI components.

8. Designing Useful Empty-State Messages

Empty-state messages should be short, clear, and relevant to the current screen. Avoid messages that simply say “Nothing” or leave users unsure about what to do next.

Recommended Content

  1. Visual element: A relevant icon or illustration.
  2. Title: A concise description of the current state.
  3. Explanation: A short sentence explaining why the content is missing.
  4. Action: A button that helps users continue, when an action is useful.

For example, a saved-items screen can display “No Saved Items” followed by “Save items to find them here later” and an action such as “Browse Items.” A search screen should instead suggest changing the search terms or removing filters.

9. Understanding Retry Actions

A retry action allows users to repeat a failed operation. Retry controls are useful when failures may be temporary, such as unstable internet connectivity, a timeout, or a temporary server error.

A retry action should invoke the original operation again, restore the loading state, clear the previous error when appropriate, and update the screen based on the new result.

9.1 Common Retry Scenarios

  • Retry loading a list after a network failure.
  • Retry loading a profile after a timeout.
  • Retry a failed image or document download.
  • Refresh content after connectivity returns.
  • Retry a read-only API request after a temporary server error.

Not every operation should be retried automatically. Retrying a POST request may create duplicate records or repeat a payment or other action if the server processed the original request but the response was lost. Use server-supported idempotency mechanisms where appropriate.

10. Creating a Retry Button in SwiftUI

A retry button can call the same asynchronous function used for the original data request.

import SwiftUI

struct RetryExampleView: View {
    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        VStack(spacing: 16) {
            if isLoading {
                ProgressView("Loading...")
            } else if let errorMessage = errorMessage {
                Text(errorMessage)
                    .multilineTextAlignment(.center)

                Button("Try Again") {
                    Task {
                        await loadData()
                    }
                }
                .buttonStyle(.borderedProminent)
            } else {
                Text("Data loaded successfully.")
            }
        }
        .padding()
    }

    @MainActor
    private func loadData() async {
        isLoading = true
        errorMessage = nil

        defer {
            isLoading = false
        }

        do {
            try await performRequest()
        } catch {
            errorMessage = "Unable to load data. Please try again."
        }
    }

    private func performRequest() async throws {
        // Replace with a real networking operation.
    }
}

This example demonstrates state transitions and a retry action. The request function is a placeholder and must be replaced with a real asynchronous operation before the view can load actual data.

11. Managing Loading, Content, Empty, and Error States

Applications become difficult to maintain when multiple Boolean variables can produce conflicting combinations, such as displaying a spinner and an empty-state message simultaneously. A common solution is to represent the screen with a single state enumeration.

11.1 Define a Screen State

import Foundation

enum ContentState {
    case idle
    case loading
    case content([Article])
    case empty
    case error(String)
}

This example assumes that an Article model has already been defined. Each case represents a different UI condition.

State Meaning UI Behavior
idle No request has started Show the initial screen.
loading A request is in progress Show a progress indicator.
content Data is available Display the data.
empty Request succeeded with no items Show an empty-state message.
error Request failed Show an error message and retry action.

11.2 Why a Single State Is Helpful

  • Prevents contradictory UI conditions.
  • Makes screen behavior easier to understand.
  • Helps ensure every request outcome has a visible result.
  • Improves unit testing and debugging.
  • Allows the view to render the correct interface using a switch statement.

12. Complete SwiftUI Example with URLSession

The following example combines a GET request, a loading indicator, an empty state, an error state, and a retry action. It uses a sample endpoint for demonstration.

import SwiftUI

struct Article: Decodable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

enum ArticleScreenState {
    case loading
    case content([Article])
    case empty
    case error(String)
}

@MainActor
final class ArticleViewModel: ObservableObject {
    @Published var state: ArticleScreenState = .loading

    func loadArticles() async {
        state = .loading

        do {
            guard let url = URL(
                string: "https://jsonplaceholder.typicode.com/posts"
            ) else {
                state = .error("The request URL is invalid.")
                return
            }

            let (data, response) = try await URLSession.shared.data(
                from: url
            )

            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                state = .error("The server could not load articles.")
                return
            }

            let articles = try JSONDecoder().decode(
                [Article].self,
                from: data
            )

            state = articles.isEmpty ? .empty : .content(articles)
        } catch {
            state = .error(
                "Unable to load articles. Check your connection and try again."
            )
        }
    }
}

struct ArticleListView: View {
    @StateObject private var viewModel = ArticleViewModel()

    var body: some View {
        NavigationStack {
            Group {
                switch viewModel.state {
                case .loading:
                    ProgressView("Loading articles...")

                case .content(let articles):
                    List(articles) { article in
                        VStack(alignment: .leading, spacing: 6) {
                            Text(article.title)
                                .font(.headline)

                            Text(article.body)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }

                case .empty:
                    ContentUnavailableView(
                        "No Articles",
                        systemImage: "doc.text",
                        description: Text(
                            "There are no articles to display."
                        )
                    )

                case .error(let message):
                    VStack(spacing: 12) {
                        Image(systemName: "wifi.exclamationmark")
                            .font(.largeTitle)

                        Text("Unable to Load Articles")
                            .font(.headline)

                        Text(message)
                            .multilineTextAlignment(.center)
                            .foregroundStyle(.secondary)

                        Button("Retry") {
                            Task {
                                await viewModel.loadArticles()
                            }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                    .padding()
                }
            }
            .navigationTitle("Articles")
            .task {
                await viewModel.loadArticles()
            }
        }
    }
}

Note: This example requires SwiftUI and the Combine framework types exposed through SwiftUI. ContentUnavailableView requires iOS 17 or later. For older deployment targets, replace it with a custom VStack. In a larger application, consider separating networking into an API service and ensuring that repeated appearances do not trigger unnecessary requests.

12.1 How the Example Works

  1. The view model starts in a loading state.
  2. The GET request retrieves articles from the API.
  3. The HTTP response status is checked.
  4. The response data is decoded into Article objects.
  5. If the array contains items, the content state is displayed.
  6. If the array is empty, the empty state is displayed.
  7. If the request or decoding fails, the error state appears.
  8. The Retry button starts the loading operation again.

13. Retry Strategies and Best Practices

Retry behavior should be designed carefully so that a temporary failure does not lead to repeated unnecessary requests.

13.1 Manual Retry

Manual retry allows the user to decide when to attempt the operation again. It is appropriate for screens with a visible error message and a Try Again button.

13.2 Automatic Retry

Automatic retries can be useful for transient failures. Apply a limited retry count and an increasing delay between attempts, commonly called exponential backoff. Respect any server-provided Retry-After guidance when appropriate.

13.3 Retry Recommendations

  • Do not retry indefinitely.
  • Avoid immediately repeating failed requests in a tight loop.
  • Retry only errors that may be temporary.
  • Do not automatically retry invalid input or most authentication failures without correcting the underlying issue.
  • Be cautious when retrying POST, payment, or other state-changing operations.
  • Cancel unnecessary requests when a screen or task no longer needs the result.
  • Disable or otherwise protect the Retry button while a request is already running.

14. Preventing Duplicate Requests

Users may tap Retry repeatedly or navigate between screens while a request is running. Without safeguards, the application may send multiple requests and display inconsistent results.

A simple safeguard is to check whether the operation is already loading.

@MainActor
func refreshData() async {
    guard !isLoading else {
        return
    }

    isLoading = true
    defer {
        isLoading = false
    }

    do {
        try await fetchData()
    } catch {
        errorMessage = "Refresh failed. Please try again."
    }
}

This snippet assumes that the containing type defines isLoading, errorMessage, and an asynchronous fetchData() function. For more complex applications, use a state machine, task cancellation, or request deduplication as appropriate.

15. Pull-to-Refresh in SwiftUI

Pull-to-refresh allows users to refresh visible content with a familiar gesture. SwiftUI provides the refreshable modifier for supported scrollable views.

import SwiftUI

struct RefreshableArticlesView: View {
    @State private var articles: [Article] = []

    var body: some View {
        List(articles) { article in
            Text(article.title)
        }
        .refreshable {
            await refreshArticles()
        }
    }

    private func refreshArticles() async {
        // Call your API service and update articles.
    }
}

The example illustrates the modifier; the refresh function must be connected to a real data source. Pull-to-refresh should preserve useful existing content where possible and show the refresh indicator without unnecessarily replacing the whole screen.

16. Accessibility Considerations

Loading indicators, empty states, and retry controls should be usable by people with different accessibility needs.

  • Use clear text labels such as “Loading articles” rather than relying only on animation.
  • Give buttons descriptive labels, such as “Retry loading articles.”
  • Maintain sufficient text contrast and readable font sizes.
  • Support Dynamic Type so text can scale to user preferences.
  • Ensure that the interface communicates errors without relying only on color.
  • Avoid unnecessary repeated announcements when a loading state changes.
  • Make sure focus and navigation remain understandable after an error or retry.

17. Common Mistakes and Troubleshooting

Problem Possible Cause Solution
Spinner never disappears Loading state is not reset after success or failure. Use defer or another reliable cleanup path.
Blank screen after loading No empty or error state is rendered. Handle all request outcomes explicitly.
Empty message appears during loading An empty collection is treated as the final result before the request finishes. Track loading state separately or use an enum.
Retry button does nothing The action is not connected to the request function. Invoke the same loading method from the button.
Repeated network requests Multiple tasks are started by repeated taps or view lifecycle events. Guard against duplicate requests and manage task lifecycles.
Old error remains after success Error state is not cleared at the start of a new attempt. Clear the previous error when retry begins.
Content disappears during refresh The app replaces existing content with a full-screen loader. Consider inline refresh feedback while retaining existing content.
Retry creates duplicate records A state-changing request is repeated without safeguards. Follow backend idempotency and retry rules.
Accessibility label is unclear The indicator or action lacks useful text. Provide descriptive labels and accessible feedback.

18. Practical Project: Articles Screen with Complete UI States

Build an iOS application that fetches articles from a remote API and provides meaningful feedback for every outcome.

Project Requirements

  1. Create an Article model that conforms to Decodable and Identifiable.
  2. Implement a networking service using URLSession.
  3. Display a loading indicator while the initial request is in progress.
  4. Show a list when articles are returned.
  5. Display an empty-state view when the response contains no articles.
  6. Display an error message when the request fails.
  7. Add a Retry button that starts another request.
  8. Prevent duplicate requests while loading.
  9. Add pull-to-refresh for existing content.
  10. Test success, empty results, offline conditions, and server errors.

Suggested Project Structure

IOSLoadingStatesApp/
├── Models/
│   └── Article.swift
├── Services/
│   └── ArticleService.swift
├── ViewModels/
│   └── ArticleViewModel.swift
└── Views/
    ├── ArticleListView.swift
    ├── LoadingView.swift
    ├── EmptyStateView.swift
    └── ErrorStateView.swift

19. Practice Exercises

  1. Create a SwiftUI screen that displays a ProgressView during a simulated delay.
  2. Create a custom empty-state component with an icon, title, description, and button.
  3. Use ContentUnavailableView on iOS 17 or later.
  4. Implement a retry action for a failed URLSession request.
  5. Ensure the loading indicator stops when an error occurs.
  6. Show a different message for empty search results and network failures.
  7. Add pull-to-refresh to a list.
  8. Disable repeated retry attempts while a request is running.
  9. Add accessible labels to progress indicators and buttons.
  10. Write tests that verify loading, content, empty, and error state transitions.

20. Interview Questions and Answers

Q1. What is a loading indicator?

A loading indicator informs users that an operation is in progress and that the final result is not yet available.

Q2. What is ProgressView in SwiftUI?

ProgressView is a SwiftUI component that displays indeterminate activity or measurable progress toward completion.

Q3. What is UIActivityIndicatorView?

It is a UIKit component used to display an indeterminate activity indicator.

Q4. What is an empty state?

An empty state explains that there is no content to display and may guide the user toward a useful next action.

Q5. What is the difference between an empty state and an error state?

An empty state normally represents a successful operation with no results, whereas an error state represents a failed operation.

Q6. What is a retry action?

A retry action allows users to attempt a failed operation again.

Q7. Why should loading and empty states not be displayed simultaneously?

They represent different stages of the operation. Displaying both can confuse users about whether data is still being fetched or the result is empty.

Q8. What is ContentUnavailableView?

It is a SwiftUI view, available from iOS 17, that provides a standard presentation for unavailable or empty content.

Q9. How can duplicate requests be prevented?

Track the loading state, reject repeated attempts while a request is active, and manage tasks and cancellation appropriately.

Q10. Should every failed request be retried automatically?

No. Retry only when appropriate. Invalid requests, authentication failures, and state-changing operations may require special handling.

Q11. Why is defer useful when managing loading state?

A defer block runs when the current scope exits, helping ensure that loading state is reset after success or failure.

Q12. How can loading states be tested?

Use controlled mock responses or a mock networking service to simulate slow requests, successful results, empty responses, and failures.

21. Quick Revision Checklist

  • Use an appropriate loading indicator for long-running work.
  • Use ProgressView in SwiftUI and UIActivityIndicatorView in UIKit when suitable.
  • Distinguish loading, content, empty, and error states.
  • Provide useful empty-state messages and relevant actions.
  • Connect retry buttons to the actual asynchronous operation.
  • Reset loading state after every request outcome.
  • Prevent duplicate requests.
  • Preserve existing content during refresh when appropriate.
  • Handle retries carefully for state-changing operations.
  • Test accessibility and different network conditions.

22. Conclusion

Loading indicators, empty states, and retry actions are essential parts of a reliable iOS user experience. They help users understand what the application is doing, explain when no content is available, and provide a path forward when a request fails.

SwiftUI offers ProgressView and ContentUnavailableView, while UIKit provides UIActivityIndicatorView. By combining these components with URLSession, clear state management, appropriate error handling, and accessible interface design, developers can build applications that remain understandable and responsive under different network conditions.

23. Additional Learning Resources

Previous Next
whatsapp